rename main code directory

This commit is contained in:
Mouse Reeve
2020-09-17 13:30:54 -07:00
parent b42faad556
commit f77c156733
199 changed files with 0 additions and 0 deletions

View File

@ -0,0 +1,20 @@
''' bring all the models into the app namespace '''
import inspect
import sys
from .book import Book, Work, Edition, Author
from .connector import Connector
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .shelf import Shelf, ShelfBook
from .status import Status, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough
from .tag import Tag
from .user import User
from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[0]: c[1].activity_serializer for c in cls_members \
if hasattr(c[1], 'activity_serializer')}

View File

@ -0,0 +1,219 @@
''' base model with default fields '''
from base64 import b64encode
from dataclasses import dataclass
from typing import Callable
from uuid import uuid4
from urllib.parse import urlencode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.db import models
from django.dispatch import receiver
from fedireads import activitypub
from fedireads.settings import DOMAIN
class FedireadsModel(models.Model):
''' shared fields '''
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
remote_id = models.CharField(max_length=255, null=True)
def get_remote_id(self):
''' generate a url that resolves to the local object '''
base_path = 'https://%s' % DOMAIN
if hasattr(self, 'user'):
base_path = self.user.remote_id
model_name = type(self).__name__.lower()
return '%s/%s/%d' % (base_path, model_name, self.id)
class Meta:
''' this is just here to provide default fields for other models '''
abstract = True
@receiver(models.signals.post_save)
def execute_after_save(sender, instance, created, *args, **kwargs):
''' set the remote_id after save (when the id is available) '''
if not created or not hasattr(instance, 'get_remote_id'):
return
if not instance.remote_id:
instance.remote_id = instance.get_remote_id()
instance.save()
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
def to_activity(self, pure=False):
''' convert from a model to an activity '''
if pure:
mappings = self.pure_activity_mappings
else:
mappings = self.activity_mappings
fields = {}
for mapping in mappings:
if not hasattr(self, mapping.model_key) or not mapping.activity_key:
continue
value = getattr(self, mapping.model_key)
if hasattr(value, 'remote_id'):
value = value.remote_id
fields[mapping.activity_key] = mapping.activity_formatter(value)
if pure:
return self.pure_activity_serializer(
**fields
).serialize()
return self.activity_serializer(
**fields
).serialize()
def to_create_activity(self, user, pure=False):
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(pure=pure)
signer = pkcs1_15.new(RSA.import_key(user.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity'
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=activity_object,
signature=signature,
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (user.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity()
).serialize()
def to_undo_activity(self, user):
''' undo an action '''
return activitypub.Undo(
id='%s#undo' % user.remote_id,
actor=user.remote_id,
object=self.to_activity()
)
class OrderedCollectionPageMixin(ActivitypubMixin):
''' just the paginator utilities, so you don't HAVE to
override ActivitypubMixin's to_activity (ie, for outbox '''
@property
def collection_remote_id(self):
''' this can be overriden if there's a special remote id, ie outbox '''
return self.remote_id
def page(self, min_id=None, max_id=None):
''' helper function to create the pagination url '''
params = {'page': 'true'}
if min_id:
params['min_id'] = min_id
if max_id:
params['max_id'] = max_id
return '?%s' % urlencode(params)
def next_page(self, items):
''' use the max id of the last item '''
if not items.count():
return ''
return self.page(max_id=items[items.count() - 1].id)
def prev_page(self, items):
''' use the min id of the first item '''
if not items.count():
return ''
return self.page(min_id=items[0].id)
def to_ordered_collection_page(self, queryset, remote_id, \
id_only=False, min_id=None, max_id=None):
''' serialize and pagiante a queryset '''
# TODO: weird place to define this
limit = 20
# filters for use in the django queryset min/max
filters = {}
if min_id is not None:
filters['id__gt'] = min_id
if max_id is not None:
filters['id__lte'] = max_id
page_id = self.page(min_id=min_id, max_id=max_id)
items = queryset.filter(
**filters
).all()[:limit]
if id_only:
page = [s.remote_id for s in items]
else:
page = [s.to_activity() for s in items]
return activitypub.OrderedCollectionPage(
id='%s%s' % (remote_id, page_id),
partOf=remote_id,
orderedItems=page,
next='%s%s' % (remote_id, self.next_page(items)),
prev='%s%s' % (remote_id, self.prev_page(items))
).serialize()
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, **kwargs):
''' an ordered collection of whatevers '''
remote_id = remote_id or self.remote_id
if page:
return self.to_ordered_collection_page(
queryset, remote_id, **kwargs)
name = ''
if hasattr(self, 'name'):
name = self.name
size = queryset.count()
return activitypub.OrderedCollection(
id=remote_id,
totalItems=size,
name=name,
first='%s%s' % (remote_id, self.page()),
last='%s%s' % (remote_id, self.page(min_id=0))
).serialize()
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
@property
def collection_queryset(self):
''' usually an ordered collection model aggregates a different model '''
raise NotImplementedError('Model must define collection_queryset')
activity_serializer = activitypub.OrderedCollection
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs)
@dataclass(frozen=True)
class ActivityMapping:
''' translate between an activitypub json field and a model field '''
activity_key: str
model_key: str
activity_formatter: Callable = lambda x: x
model_formatter: Callable = lambda x: x

224
bookwyrm/models/book.py Normal file
View File

@ -0,0 +1,224 @@
''' database schema for books and shelves '''
from django.db import models
from django.utils import timezone
from django.utils.http import http_date
from model_utils.managers import InheritanceManager
from fedireads import activitypub
from fedireads.settings import DOMAIN
from fedireads.utils.fields import ArrayField
from .base_model import ActivityMapping, ActivitypubMixin, FedireadsModel
class Book(ActivitypubMixin, FedireadsModel):
''' a generic book, which can mean either an edition or a work '''
# these identifiers apply to both works and editions
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
librarything_key = models.CharField(max_length=255, blank=True, null=True)
goodreads_key = models.CharField(max_length=255, blank=True, null=True)
# info about where the data comes from and where/if to sync
sync = models.BooleanField(default=True)
sync_cover = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
connector = models.ForeignKey(
'Connector', on_delete=models.PROTECT, null=True)
# TODO: edit history
# book/work metadata
title = models.CharField(max_length=255)
sort_title = models.CharField(max_length=255, blank=True, null=True)
subtitle = models.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True)
languages = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
series = models.CharField(max_length=255, blank=True, null=True)
series_number = models.CharField(max_length=255, blank=True, null=True)
subjects = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
subject_places = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
# TODO: include an annotation about the type of authorship (ie, translator)
authors = models.ManyToManyField('Author')
# preformatted authorship string for search and easier display
author_text = models.CharField(max_length=255, blank=True, null=True)
cover = models.ImageField(upload_to='covers/', blank=True, null=True)
first_published_date = models.DateTimeField(blank=True, null=True)
published_date = models.DateTimeField(blank=True, null=True)
objects = InheritanceManager()
@property
def ap_authors(self):
''' the activitypub serialization should be a list of author ids '''
return [a.remote_id for a in self.authors.all()]
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('authors', 'ap_authors'),
ActivityMapping(
'first_published_date',
'first_published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping(
'published_date',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping('title', 'title'),
ActivityMapping('sort_title', 'sort_title'),
ActivityMapping('subtitle', 'subtitle'),
ActivityMapping('description', 'description'),
ActivityMapping('languages', 'languages'),
ActivityMapping('series', 'series'),
ActivityMapping('series_number', 'series_number'),
ActivityMapping('subjects', 'subjects'),
ActivityMapping('subject_places', 'subject_places'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('librarything_key', 'librarything_key'),
ActivityMapping('goodreads_key', 'goodreads_key'),
ActivityMapping('work', 'parent_work'),
ActivityMapping('isbn_10', 'isbn_10'),
ActivityMapping('isbn_13', 'isbn_13'),
ActivityMapping('oclc_number', 'oclc_number'),
ActivityMapping('asin', 'asin'),
ActivityMapping('pages', 'pages'),
ActivityMapping('physical_format', 'physical_format'),
ActivityMapping('publishers', 'publishers'),
ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'),
]
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works')
super().save(*args, **kwargs)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
return 'https://%s/book/%d' % (DOMAIN, self.id)
@property
def local_id(self):
''' when a book is ingested from an outside source, it becomes local to
an instance, so it needs a local url for federation. but it still needs
the remote_id for easier deduplication and, if appropriate, to sync with
the remote canonical copy '''
return 'https://%s/book/%d' % (DOMAIN, self.id)
def __repr__(self):
return "<{} key={!r} title={!r}>".format(
self.__class__,
self.openlibrary_key,
self.title,
)
class Work(Book):
''' a work (an abstract concept of a book that manifests in an edition) '''
# library of congress catalog control number
lccn = models.CharField(max_length=255, blank=True, null=True)
@property
def editions_path(self):
''' it'd be nice to serialize the edition instead but, recursion '''
return self.remote_id + '/editions'
@property
def default_edition(self):
''' best-guess attempt at picking the default edition for this work '''
ed = Edition.objects.filter(parent_work=self, default=True).first()
if not ed:
ed = Edition.objects.filter(parent_work=self).first()
return ed
activity_serializer = activitypub.Work
class Edition(Book):
''' an edition of a book '''
# default -> this is what gets displayed for a work
default = models.BooleanField(default=False)
# these identifiers only apply to editions, not works
isbn_10 = models.CharField(max_length=255, blank=True, null=True)
isbn_13 = models.CharField(max_length=255, blank=True, null=True)
oclc_number = models.CharField(max_length=255, blank=True, null=True)
asin = models.CharField(max_length=255, blank=True, null=True)
pages = models.IntegerField(blank=True, null=True)
physical_format = models.CharField(max_length=255, blank=True, null=True)
publishers = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
shelves = models.ManyToManyField(
'Shelf',
symmetrical=False,
through='ShelfBook',
through_fields=('book', 'shelf')
)
parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True)
activity_serializer = activitypub.Edition
class Author(ActivitypubMixin, FedireadsModel):
''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = models.CharField(max_length=255, blank=True, null=True)
# idk probably other keys would be useful here?
born = models.DateTimeField(blank=True, null=True)
died = models.DateTimeField(blank=True, null=True)
name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255, blank=True, null=True)
first_name = models.CharField(max_length=255, blank=True, null=True)
aliases = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = models.TextField(null=True, blank=True)
@property
def local_id(self):
''' when a book is ingested from an outside source, it becomes local to
an instance, so it needs a local url for federation. but it still needs
the remote_id for easier deduplication and, if appropriate, to sync with
the remote canonical copy (ditto here for author)'''
return 'https://%s/book/%d' % (DOMAIN, self.id)
@property
def display_name(self):
''' Helper to return a displayable name'''
if self.name:
return self.name
# don't want to return a spurious space if all of these are None
if self.first_name and self.last_name:
return self.first_name + ' ' + self.last_name
return self.last_name or self.first_name
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('name', 'display_name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('wikipedia_link', 'wikipedia_link'),
]
activity_serializer = activitypub.Author

View File

@ -0,0 +1,40 @@
''' manages interfaces with external sources of book data '''
from django.db import models
from fedireads.connectors.settings import CONNECTORS
from .base_model import FedireadsModel
ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
class Connector(FedireadsModel):
''' book data source connectors '''
identifier = models.CharField(max_length=255, unique=True)
priority = models.IntegerField(default=2)
name = models.CharField(max_length=255, null=True)
local = models.BooleanField(default=False)
connector_file = models.CharField(
max_length=255,
choices=ConnectorFiles.choices
)
api_key = models.CharField(max_length=255, null=True)
base_url = models.CharField(max_length=255)
books_url = models.CharField(max_length=255)
covers_url = models.CharField(max_length=255)
search_url = models.CharField(max_length=255, null=True)
politeness_delay = models.IntegerField(null=True) #seconds
max_query_count = models.IntegerField(null=True)
# how many queries executed in a unit of time, like a day
query_count = models.IntegerField(default=0)
# when to reset the query count back to 0 (ie, after 1 day)
query_count_expiry = models.DateTimeField(auto_now_add=True)
class Meta:
''' check that there's code to actually use this connector '''
constraints = [
models.CheckConstraint(
check=models.Q(connector_file__in=ConnectorFiles),
name='connector_file_valid'
)
]

View File

@ -0,0 +1,15 @@
''' connections to external ActivityPub servers '''
from django.db import models
from .base_model import FedireadsModel
class FederatedServer(FedireadsModel):
''' store which server's we federate with '''
server_name = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else
status = models.CharField(max_length=255, default='federated')
# is it mastodon, fedireads, etc
application_type = models.CharField(max_length=255, null=True)
application_version = models.CharField(max_length=255, null=True)
# TODO: blocked servers

View File

@ -0,0 +1,121 @@
''' track progress of goodreads imports '''
import re
import dateutil.parser
from django.db import models
from django.utils import timezone
from fedireads import books_manager
from fedireads.models import ReadThrough, User, Book
from fedireads.utils.fields import JSONField
# Mapping goodreads -> fedireads shelf titles.
GOODREADS_SHELVES = {
'read': 'read',
'currently-reading': 'reading',
'to-read': 'to-read',
}
def unquote_string(text):
''' resolve csv quote weirdness '''
match = re.match(r'="([^"]*)"', text)
if match:
return match.group(1)
return text
def construct_search_term(title, author):
''' formulate a query for the data connector '''
# Strip brackets (usually series title from search term)
title = re.sub(r'\s*\([^)]*\)\s*', '', title)
# Open library doesn't like including author initials in search term.
author = re.sub(r'(\w\.)+\s*', '', author)
return ' '.join([title, author])
class ImportJob(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_date = models.DateTimeField(default=timezone.now)
task_id = models.CharField(max_length=100, null=True)
import_status = models.ForeignKey(
'Status', null=True, on_delete=models.PROTECT)
class ImportItem(models.Model):
job = models.ForeignKey(
ImportJob,
on_delete=models.CASCADE,
related_name='items')
index = models.IntegerField()
data = JSONField()
book = models.ForeignKey(
Book, on_delete=models.SET_NULL, null=True, blank=True)
fail_reason = models.TextField(null=True)
def resolve(self):
''' try various ways to lookup a book '''
self.book = (
self.get_book_from_isbn() or
self.get_book_from_title_author()
)
def get_book_from_isbn(self):
''' search by isbn '''
search_result = books_manager.first_search_result(self.isbn)
if search_result:
return books_manager.get_or_create_book(search_result.key)
def get_book_from_title_author(self):
''' search by title and author '''
search_term = construct_search_term(
self.data['Title'],
self.data['Author']
)
search_result = books_manager.first_search_result(search_term)
if search_result:
return books_manager.get_or_create_book(search_result.key)
@property
def isbn(self):
return unquote_string(self.data['ISBN13'])
@property
def shelf(self):
''' the goodreads shelf field '''
if self.data['Exclusive Shelf']:
return GOODREADS_SHELVES.get(self.data['Exclusive Shelf'])
@property
def review(self):
return self.data['My Review']
@property
def rating(self):
return int(self.data['My Rating'])
@property
def date_added(self):
if self.data['Date Added']:
return dateutil.parser.parse(self.data['Date Added'])
@property
def date_read(self):
if self.data['Date Read']:
return dateutil.parser.parse(self.data['Date Read'])
@property
def reads(self):
if (self.shelf == 'reading'
and self.date_added and not self.date_read):
return [ReadThrough(start_date=self.date_added)]
if self.date_read:
return [ReadThrough(
finish_date=self.date_read,
)]
return []
def __repr__(self):
return "<GoodreadsItem {!r}>".format(self.data['Title'])
def __str__(self):
return "{} by {}".format(self.data['Title'], self.data['Author'])

View File

@ -0,0 +1,89 @@
''' defines relationships between users '''
from django.db import models
from fedireads import activitypub
from .base_model import FedireadsModel
class UserRelationship(FedireadsModel):
''' many-to-many through table for followers '''
user_subject = models.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='%(class)s_user_subject'
)
user_object = models.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='%(class)s_user_object'
)
# follow or follow_request for pending TODO: blocking?
relationship_id = models.CharField(max_length=100)
class Meta:
''' relationships should be unique '''
abstract = True
constraints = [
models.UniqueConstraint(
fields=['user_subject', 'user_object'],
name='%(class)s_unique'
),
models.CheckConstraint(
check=~models.Q(user_subject=models.F('user_object')),
name='%(class)s_no_self'
)
]
def get_remote_id(self):
''' use shelf identifier in remote_id '''
base_path = self.user_subject.remote_id
return '%s#%s/%d' % (base_path, self.status, self.id)
class UserFollows(UserRelationship):
''' Following a user '''
status = 'follows'
@classmethod
def from_request(cls, follow_request):
''' converts a follow request into a follow relationship '''
return cls(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
relationship_id=follow_request.relationship_id,
)
class UserFollowRequest(UserRelationship):
''' following a user requires manual or automatic confirmation '''
status = 'follow_request'
def to_activity(self):
''' request activity '''
return activitypub.Follow(
id=self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
def to_accept_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Accept(
id='%s#accepts/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
def to_reject_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Reject(
id='%s#rejects/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
class UserBlocks(UserRelationship):
''' prevent another user from following you and seeing your posts '''
# TODO: not implemented
status = 'blocks'

69
bookwyrm/models/shelf.py Normal file
View File

@ -0,0 +1,69 @@
''' puttin' books on shelves '''
from django.db import models
from fedireads import activitypub
from .base_model import FedireadsModel, OrderedCollectionMixin
class Shelf(OrderedCollectionMixin, FedireadsModel):
''' a list of books owned by a user '''
name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT)
editable = models.BooleanField(default=True)
books = models.ManyToManyField(
'Edition',
symmetrical=False,
through='ShelfBook',
through_fields=('shelf', 'book')
)
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books
def get_remote_id(self):
''' shelf identifier instead of id '''
base_path = self.user.remote_id
return '%s/shelf/%s' % (base_path, self.identifier)
class Meta:
''' user/shelf unqiueness '''
unique_together = ('user', 'identifier')
class ShelfBook(FedireadsModel):
''' many to many join table for books and shelves '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
added_by = models.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
)
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
).serialize()
class Meta:
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf')

45
bookwyrm/models/site.py Normal file
View File

@ -0,0 +1,45 @@
import base64
from Crypto import Random
from django.db import models
from django.utils import timezone
import datetime
from fedireads.settings import DOMAIN
from .user import User
class SiteSettings(models.Model):
name = models.CharField(default=DOMAIN, max_length=100)
instance_description = models.TextField(
default="This instance has no description.")
code_of_conduct = models.TextField(
default="Add a code of conduct here.")
allow_registration = models.BooleanField(default=True)
@classmethod
def get(cls):
try:
return cls.objects.get(id=1)
except cls.DoesNotExist:
default_settings = SiteSettings(id=1)
default_settings.save()
return default_settings
def new_invite_code():
return base64.b32encode(Random.get_random_bytes(5)).decode('ascii')
class SiteInvite(models.Model):
code = models.CharField(max_length=32, default=new_invite_code)
expiry = models.DateTimeField(blank=True, null=True)
use_limit = models.IntegerField(blank=True, null=True)
times_used = models.IntegerField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE)
def valid(self):
return (
(self.expiry is None or self.expiry > timezone.now()) and
(self.use_limit is None or self.times_used < self.use_limit))
@property
def link(self):
return "https://{}/invite/{}".format(DOMAIN, self.code)

246
bookwyrm/models/status.py Normal file
View File

@ -0,0 +1,246 @@
''' models for storing different kinds of Activities '''
from django.utils import timezone
from django.utils.http import http_date
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from model_utils.managers import InheritanceManager
from fedireads import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import ActivityMapping, FedireadsModel
class Status(OrderedCollectionPageMixin, FedireadsModel):
''' any post, like a reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
content = models.TextField(blank=True, null=True)
mention_users = models.ManyToManyField('User', related_name='mention_user')
mention_books = models.ManyToManyField(
'Edition', related_name='mention_book')
local = models.BooleanField(default=True)
privacy = models.CharField(max_length=255, default='public')
sensitive = models.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts
published_date = models.DateTimeField(default=timezone.now)
favorites = models.ManyToManyField(
'User',
symmetrical=False,
through='Favorite',
through_fields=('status', 'user'),
related_name='user_favorites'
)
reply_parent = models.ForeignKey(
'self',
null=True,
on_delete=models.PROTECT
)
objects = InheritanceManager()
# ---- activitypub serialization settings for this model ----- #
@property
def ap_to(self):
''' should be related to post privacy I think '''
return ['https://www.w3.org/ns/activitystreams#Public']
@property
def ap_cc(self):
''' should be related to post privacy I think '''
return [self.user.ap_followers]
@property
def ap_replies(self):
''' structured replies block '''
return self.to_replies()
shared_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('inReplyTo', 'reply_parent'),
ActivityMapping(
'published',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp())
),
ActivityMapping('attributedTo', 'user'),
ActivityMapping('to', 'ap_to'),
ActivityMapping('cc', 'ap_cc'),
ActivityMapping('replies', 'ap_replies'),
]
# serializing to fedireads expanded activitypub
activity_mappings = shared_mappings + [
ActivityMapping('name', 'name'),
ActivityMapping('inReplyToBook', 'book'),
ActivityMapping('rating', 'rating'),
ActivityMapping('quote', 'quote'),
ActivityMapping('content', 'content'),
]
# for serializing to standard activitypub without extended types
pure_activity_mappings = shared_mappings + [
ActivityMapping('name', 'pure_ap_name'),
ActivityMapping('content', 'ap_pure_content'),
]
activity_serializer = activitypub.Note
#----- replies collection activitypub ----#
@classmethod
def replies(cls, status):
''' load all replies to a status. idk if there's a better way
to write this so it's just a property '''
return cls.objects.filter(reply_parent=status).select_subclasses()
def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection(
self.replies(self),
remote_id='%s/replies' % self.remote_id,
**kwargs
)
class Comment(Status):
''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
@property
def ap_pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note
class Quotation(Status):
''' like a review but without a rating and transient '''
quote = models.TextField()
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
@property
def ap_pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return '"%s"<br>-- <a href="%s">"%s"</a>)<br><br>%s' % (
self.quote,
self.book.local_id,
self.book.title,
self.content,
)
activity_serializer = activitypub.Quotation
class Review(Status):
''' a book review '''
name = models.CharField(max_length=255, null=True)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
rating = models.IntegerField(
default=None,
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
@property
def ap_pure_name(self):
''' clarify review names for mastodon serialization '''
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
self.name
)
@property
def ap_pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
activity_serializer = activitypub.Review
class Favorite(ActivitypubMixin, FedireadsModel):
''' fav'ing a post '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
status = models.ForeignKey('Status', on_delete=models.PROTECT)
# ---- activitypub serialization settings for this model ----- #
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'status'),
]
activity_serializer = activitypub.Like
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')
class Boost(Status):
''' boost'ing a post '''
boosted_status = models.ForeignKey(
'Status',
on_delete=models.PROTECT,
related_name="boosters")
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'boosted_status'),
]
activity_serializer = activitypub.Like
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
class ReadThrough(FedireadsModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Book', on_delete=models.PROTECT)
pages_read = models.IntegerField(
null=True,
blank=True)
start_date = models.DateTimeField(
blank=True,
null=True)
finish_date = models.DateTimeField(
blank=True,
null=True)
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(FedireadsModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
)
]

60
bookwyrm/models/tag.py Normal file
View File

@ -0,0 +1,60 @@
''' models for storing different kinds of Activities '''
import urllib.parse
from django.db import models
from fedireads import activitypub
from fedireads.settings import DOMAIN
from .base_model import OrderedCollectionMixin, FedireadsModel
class Tag(OrderedCollectionMixin, FedireadsModel):
''' freeform tags for books '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100)
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(identifier=identifier)
@property
def collection_queryset(self):
''' books associated with this tag '''
return self.book_queryset(self.identifier)
def get_remote_id(self):
''' tag should use identifier not id in remote_id '''
base_path = 'https://%s' % DOMAIN
return '%s/tag/%s' % (base_path, self.identifier)
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
).serialize()
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'book', 'name')

218
bookwyrm/models/user.py Normal file
View File

@ -0,0 +1,218 @@
''' database schema for user data '''
from urllib.parse import urlparse
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
from fedireads import activitypub
from fedireads.models.shelf import Shelf
from fedireads.models.status import Status
from fedireads.settings import DOMAIN
from fedireads.signatures import create_key_pair
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivityMapping
class User(OrderedCollectionPageMixin, AbstractUser):
''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True)
inbox = models.CharField(max_length=255, unique=True)
shared_inbox = models.CharField(max_length=255, blank=True, null=True)
federated_server = models.ForeignKey(
'FederatedServer',
on_delete=models.PROTECT,
null=True,
)
outbox = models.CharField(max_length=255, unique=True)
summary = models.TextField(blank=True, null=True)
local = models.BooleanField(default=True)
fedireads_user = models.BooleanField(default=True)
localname = models.CharField(
max_length=255,
null=True,
unique=True
)
# name is your display name, which you can change at will
name = models.CharField(max_length=100, blank=True, null=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
following = models.ManyToManyField(
'self',
symmetrical=False,
through='UserFollows',
through_fields=('user_subject', 'user_object'),
related_name='followers'
)
follow_requests = models.ManyToManyField(
'self',
symmetrical=False,
through='UserFollowRequest',
through_fields=('user_subject', 'user_object'),
related_name='follower_requests'
)
blocks = models.ManyToManyField(
'self',
symmetrical=False,
through='UserBlocks',
through_fields=('user_subject', 'user_object'),
related_name='blocked_by'
)
favorites = models.ManyToManyField(
'Status',
symmetrical=False,
through='Favorite',
through_fields=('user', 'status'),
related_name='favorite_statuses'
)
remote_id = models.CharField(max_length=255, null=True, unique=True)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.BooleanField(default=False)
# ---- activitypub serialization settings for this model ----- #
@property
def ap_followers(self):
''' generates url for activitypub followers page '''
return '%s/followers' % self.remote_id
@property
def ap_icon(self):
''' send default icon if one isn't set '''
if self.avatar:
url = self.avatar.url
# TODO not the right way to get the media type
media_type = 'image/%s' % url.split('.')[-1]
else:
url = '%s/static/images/default_avi.jpg' % DOMAIN
media_type = 'image/jpeg'
return activitypub.Image(media_type, url, 'Image')
@property
def ap_public_key(self):
''' format the public key block for activitypub '''
return activitypub.PublicKey(**{
'id': '%s/#main-key' % self.remote_id,
'owner': self.remote_id,
'publicKeyPem': self.public_key,
})
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping(
'preferredUsername',
'username',
activity_formatter=lambda x: x.split('@')[0]
),
ActivityMapping('name', 'name'),
ActivityMapping('inbox', 'inbox'),
ActivityMapping('outbox', 'outbox'),
ActivityMapping('followers', 'ap_followers'),
ActivityMapping('summary', 'summary'),
ActivityMapping(
'publicKey',
'public_key',
model_formatter=lambda x: x.get('publicKeyPem')
),
ActivityMapping('publicKey', 'ap_public_key'),
ActivityMapping(
'endpoints',
'shared_inbox',
activity_formatter=lambda x: {'sharedInbox': x},
model_formatter=lambda x: x.get('sharedInbox')
),
ActivityMapping('icon', 'ap_icon'),
ActivityMapping(
'manuallyApprovesFollowers',
'manually_approves_followers'
),
# this field isn't in the activity but should always be false
ActivityMapping(None, 'local', model_formatter=lambda x: False),
]
activity_serializer = activitypub.Person
def to_outbox(self, **kwargs):
''' an ordered collection of statuses '''
queryset = Status.objects.filter(
user=self,
).select_subclasses()
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
def to_following_activity(self, **kwargs):
''' activitypub following list '''
remote_id = '%s/following' % self.remote_id
return self.to_ordered_collection(self.following, \
remote_id=remote_id, id_only=True, **kwargs)
def to_followers_activity(self, **kwargs):
''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id
return self.to_ordered_collection(self.followers, \
remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
activity_object['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'schema': 'http://schema.org#',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
}
]
return activity_object
@receiver(models.signals.pre_save, sender=User)
def execute_before_save(sender, instance, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to poplate fields
if instance.id:
return
if not instance.local:
# we need to generate a username that uses the domain (webfinger format)
actor_parts = urlparse(instance.remote_id)
instance.username = '%s@%s' % (instance.username, actor_parts.netloc)
return
# populate fields for local users
instance.remote_id = 'https://%s/user/%s' % (DOMAIN, instance.username)
instance.localname = instance.username
instance.username = '%s@%s' % (instance.username, DOMAIN)
instance.actor = instance.remote_id
instance.inbox = '%s/inbox' % instance.remote_id
instance.shared_inbox = 'https://%s/inbox' % DOMAIN
instance.outbox = '%s/outbox' % instance.remote_id
if not instance.private_key:
instance.private_key, instance.public_key = create_key_pair()
@receiver(models.signals.post_save, sender=User)
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
if not instance.local or not created:
return
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=instance,
editable=False
).save()