''' database schema for the whole dang thing ''' from django.db import models from model_utils.managers import InheritanceManager from django.dispatch import receiver from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from Crypto import Random from Crypto.PublicKey import RSA import re from fedireads.settings import DOMAIN, OL_URL class User(AbstractUser): ''' a user who wants to read books ''' private_key = models.TextField(blank=True, null=True) public_key = models.TextField(blank=True, null=True) api_key = models.CharField(max_length=255, blank=True, null=True) actor = models.CharField(max_length=255, unique=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) 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) followers = models.ManyToManyField('self', symmetrical=False) created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) @receiver(models.signals.pre_save, sender=User) def execute_before_save(sender, instance, *args, **kwargs): ''' create shelves for new users ''' # this user already exists, no need to poplate fields if instance.id or not instance.local: return # populate fields for local users instance.localname = instance.username instance.username = '%s@%s' % (instance.username, DOMAIN) instance.actor = 'https://%s/user/%s' % (DOMAIN, instance.localname) instance.inbox = 'https://%s/user/%s/inbox' % (DOMAIN, instance.localname) instance.shared_inbox = 'https://%s/inbox' % DOMAIN instance.outbox = 'https://%s/user/%s/outbox' % (DOMAIN, instance.localname) if not instance.private_key: random_generator = Random.new().read key = RSA.generate(1024, random_generator) instance.private_key = key.export_key().decode('utf8') instance.public_key = key.publickey().export_key().decode('utf8') @receiver(models.signals.post_save, sender=User) def execute_after_save(sender, instance, created, *args, **kwargs): ''' create shelves for new users ''' # TODO: how are remote users handled? what if they aren't readers? if not instance.local or not created: return shelves = [{ 'name': 'To Read', 'type': 'to-read', }, { 'name': 'Currently Reading', 'type': 'reading', }, { 'name': 'Read', 'type': 'read', }] for shelf in shelves: Shelf( name=shelf['name'], shelf_type=shelf['type'], user=instance, editable=False ).save() class FederatedServer(models.Model): ''' store which server's we federate with ''' server_name = models.CharField(max_length=255, unique=True) shared_inbox = 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) class Activity(models.Model): ''' basic fields for storing activities ''' uuid = models.CharField(max_length=255, unique=True) user = models.ForeignKey('User', on_delete=models.PROTECT) content = JSONField(max_length=5000) # the activitypub activity type (Create, Add, Follow, ...) activity_type = models.CharField(max_length=255) # custom types internal to fedireads (Review, Shelve, ...) fedireads_type = models.CharField(max_length=255, blank=True, null=True) created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) objects = InheritanceManager() class ShelveActivity(Activity): ''' someone put a book on a shelf ''' book = models.ForeignKey('Book', on_delete=models.PROTECT) shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) def save(self, *args, **kwargs): if not self.activity_type: self.activity_type = 'Add' self.fedireads_type = 'Shelve' super().save(*args, **kwargs) class FollowActivity(Activity): ''' record follow requests sent out ''' followed = models.ForeignKey( 'User', related_name='followed', on_delete=models.PROTECT ) def save(self, *args, **kwargs): self.activity_type = 'Follow' super().save(*args, **kwargs) def validate_rating(rating): ''' only accept 0-5 as star rating ints ''' if rating < 0 or rating > 5: raise ValidationError('Rating must be 0-5') class Review(Activity): ''' a book review ''' book = models.ForeignKey('Book', on_delete=models.PROTECT) name = models.CharField(max_length=255) rating = models.IntegerField(default=0, validators=[validate_rating]) review_content = models.TextField() def save(self, *args, **kwargs): self.activity_type = 'Article' self.fedireads_type = 'Review' super().save(*args, **kwargs) class Note(Activity): ''' reply to a review, etc ''' def save(self, *args, **kwargs): self.activity_type = 'Note' super().save(*args, **kwargs) class Shelf(models.Model): activitypub_id = models.CharField(max_length=255) identifier = models.CharField(max_length=255, unique=True) name = models.CharField(max_length=100) user = models.ForeignKey('User', on_delete=models.PROTECT) editable = models.BooleanField(default=True) shelf_type = models.CharField(default='custom', max_length=100) books = models.ManyToManyField( 'Book', symmetrical=False, through='ShelfBook', through_fields=('shelf', 'book') ) created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) class Meta: unique_together = ('user', 'name') def save(self, *args, **kwargs): if not self.identifier: self.identifier = '%s_%s' % ( self.user.localname, re.sub(r'\W', '-', self.name).lower() ) if not self.activitypub_id: self.activitypub_id = 'https://%s/shelf/%s' % \ (DOMAIN, self.identifier) super().save(*args, **kwargs) class ShelfBook(models.Model): # many to many join table for books and shelves book = models.ForeignKey('Book', 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 ) added_date = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('book', 'shelf') class Book(models.Model): ''' a non-canonical copy of a work (not book) from open library ''' activitypub_id = models.CharField(max_length=255) openlibrary_key = models.CharField(max_length=255, unique=True) data = JSONField() authors = models.ManyToManyField('Author') # TODO: also store cover thumbnail cover = models.ImageField(upload_to='covers/', blank=True, null=True) shelves = models.ManyToManyField( 'Shelf', symmetrical=False, through='ShelfBook', through_fields=('book', 'shelf') ) added_by = models.ForeignKey( 'User', blank=True, null=True, on_delete=models.PROTECT ) added_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) def save(self, *args, **kwargs): self.activitypub_id = '%s%s' % (OL_URL, self.openlibrary_key) super().save(*args, **kwargs) class Author(models.Model): openlibrary_key = models.CharField(max_length=255) data = JSONField() added_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True)