diff --git a/fedireads/migrations/0020_auto_20200327_2335.py b/fedireads/migrations/0020_auto_20200327_2335.py index 511b1bed..09719860 100644 --- a/fedireads/migrations/0020_auto_20200327_2335.py +++ b/fedireads/migrations/0020_auto_20200327_2335.py @@ -48,7 +48,7 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='connector', - constraint=models.CheckConstraint(check=models.Q(connector_file__in=fedireads.models.book.ConnectorFiles), name='connector_file_valid'), + constraint=models.CheckConstraint(check=models.Q(connector_file__in=fedireads.models.connector.ConnectorFiles), name='connector_file_valid'), ), migrations.AddField( model_name='book', diff --git a/fedireads/models/__init__.py b/fedireads/models/__init__.py index 90f3b2c2..b941d447 100644 --- a/fedireads/models/__init__.py +++ b/fedireads/models/__init__.py @@ -2,12 +2,15 @@ import inspect import sys -from .book import Connector, Book, Work, Edition, Author +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, Tag, Notification, ReadThrough -from .user import User, UserFollows, UserFollowRequest, UserBlocks -from .user import FederatedServer +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 diff --git a/fedireads/models/book.py b/fedireads/models/book.py index 40ea8b6f..a6ff792a 100644 --- a/fedireads/models/book.py +++ b/fedireads/models/book.py @@ -7,45 +7,10 @@ from model_utils.managers import InheritanceManager from fedireads import activitypub from fedireads.settings import DOMAIN from fedireads.utils.fields import ArrayField -from fedireads.connectors.settings import CONNECTORS from .base_model import ActivityMapping, ActivitypubMixin, 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: - constraints = [ - models.CheckConstraint( - check=models.Q(connector_file__in=ConnectorFiles), - name='connector_file_valid' - ) - ] - - class Book(ActivitypubMixin, FedireadsModel): ''' a generic book, which can mean either an edition or a work ''' # these identifiers apply to both works and editions @@ -89,6 +54,7 @@ class Book(ActivitypubMixin, FedireadsModel): @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 = [ @@ -168,11 +134,13 @@ class Work(Book): @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() diff --git a/fedireads/models/connector.py b/fedireads/models/connector.py new file mode 100644 index 00000000..81e4d674 --- /dev/null +++ b/fedireads/models/connector.py @@ -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' + ) + ] diff --git a/fedireads/models/federated_server.py b/fedireads/models/federated_server.py new file mode 100644 index 00000000..9af05b7a --- /dev/null +++ b/fedireads/models/federated_server.py @@ -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 diff --git a/fedireads/models/relationship.py b/fedireads/models/relationship.py new file mode 100644 index 00000000..a1c55f8c --- /dev/null +++ b/fedireads/models/relationship.py @@ -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' diff --git a/fedireads/models/status.py b/fedireads/models/status.py index eb43130d..87b5c93f 100644 --- a/fedireads/models/status.py +++ b/fedireads/models/status.py @@ -1,6 +1,4 @@ ''' models for storing different kinds of Activities ''' -import urllib.parse - from django.utils import timezone from django.utils.http import http_date from django.core.validators import MaxValueValidator, MinValueValidator @@ -8,9 +6,7 @@ from django.db import models from model_utils.managers import InheritanceManager from fedireads import activitypub -from fedireads.settings import DOMAIN -from .base_model import ActivitypubMixin, OrderedCollectionMixin, \ - OrderedCollectionPageMixin +from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivityMapping, FedireadsModel @@ -205,59 +201,6 @@ class Boost(Status): # unique_together = ('user', 'boosted_status') -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') - - class ReadThrough(FedireadsModel): ''' Store progress through a book in the database. ''' user = models.ForeignKey('User', on_delete=models.PROTECT) diff --git a/fedireads/models/tag.py b/fedireads/models/tag.py new file mode 100644 index 00000000..5703f0a7 --- /dev/null +++ b/fedireads/models/tag.py @@ -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') diff --git a/fedireads/models/user.py b/fedireads/models/user.py index 71eb6181..9b439143 100644 --- a/fedireads/models/user.py +++ b/fedireads/models/user.py @@ -11,7 +11,7 @@ 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, FedireadsModel +from .base_model import ActivityMapping class User(OrderedCollectionPageMixin, AbstractUser): @@ -168,104 +168,6 @@ class User(OrderedCollectionPageMixin, AbstractUser): return activity_object -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 ''' - @property - def status(self): - return '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 ''' - @property - def status(self): - return '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): - @property - def status(self): - return 'blocks' - - -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) - - @receiver(models.signals.pre_save, sender=User) def execute_before_save(sender, instance, *args, **kwargs): ''' populate fields for new local users '''